Глава 8. Классы С++

В прошлой главе мы показали, как определяются простейшие классы C++. То, что содержится в приведенном коде — это интерфейс класса. В самом определении класса объявляются обычно лишь прототипы функций-элементов. Чтобы класс стал работоспособным, необходима еще его реализация. Реализация класса, располагаемая часто в отдельном файле, содержит код его функций-элементов, а также некоторые элементы данных, называемые статическими.

Мы переходим теперь к подробному изучению классов, включая, естественно, и аспекты их реализации.

Элементы класса

Как уже говорилось раньше, элементы класса распадаются на две категории. Это данные, инкапсулирующие состояние объектов, и код, отвечающий за их поведение и реализуемый в форме функций-элементов класса.

Элементы данных

Суммируем и дополним то, что говорилось об элементах данных в предыдущей главе. Элементы данных аналогичны элементам структур языка С. Стоит добавить следующее:

Элементы-функции

Функция-элемент класса объявляется внутри определения класса. Там же может быть расположено и оределение тела функции. В этом случае функцию-элемент называют встроенной и компилятор будет генерировать ее встроенное расширение на месте вызова. Если определение функции располагается вне тела класса, то к ее имени добавляется префикс, состоящий из имени класса и операции разрешения области действия. В этом случае функцию-элемент также можно определить как встроенную с помощью ключевого слова inline. Вот несколько модифицированный класс Point из предыдущей главы вместе с его реализацией:

#include <assert.h>

const int MaxX = 200; // Максимальные значения координат.

const int MaxY = 200;

//

struct Point { // Класс.точек.

private:

int fx;

int fy;

public:

int GetX(void) ( return fx; }

int GetY(void) { return fy; }

void SetPoint(int, int);

};

void Point::SetPoint(int x, int y)

{

assert(x >=0 && x < MaxX);

assert(y >= 0 && у < MaxY);

fx = x;

fy = y;

}

Здесь обе функции Get () определены как встроенные, а функция SetPoint () определяется вне тела класса и не является встроенной.

Класс как область действия

В языке С выделялось несколько различных типов области действия: глобальная, файл, функция, блок. В C++'вводится новый вид области действия — класс. Имена элементов класса расположены в области действия класса, и к ним можно обращаться из функций-элементов данного класса. Кроме того, получить доступ к элементам класса можно в следующих случаях:

Доступ к элементам данных

Поскольку функции-элементы класса находятся в его области действия, они могут обращаться к элементам данных непосредственно по имени, как можно видеть в последнем примере. Обычные функции или функции-элементы других классов могут обращаться к элементам существующего представителя класса с помощью операций “.” или “->”:

class Time { public:

int hour;

int min;

} ;

int main()

{

Time start; // Локальный объект Time.

Time *pTime = Sstart; //Указатель на локальный объект.

start.hour = 17; // Операция доступа к элементу.

pTime->min = 30; // Косвенный доступ к элементу.

return 0;

}

Вызов функций-элементов класса

Совершенно аналогично тому, что имеет место в случае элементов-данных, функции-элементы класса могут вызываться функциями-элементами того же класса просто по имени. Обычные функции и элементы других классов могут вызывать функции-элементы данного класса для существующих его представителей с помощью операций “ . ” или “->” (через указатель). Приведенный ниже пример это иллюстрирует.

#include <stdio.h>

class Time ( int hour;

int min;

public:

void SetTime(int h, int m)

{

hour = h; min = m; } void ShowTime(void)

{

printf("Time: %02d:%02d.\n", hour, min);

}

};

int main()

{

Time start;

Time *pStart = &start;

int hr, min;

start.SetTime(17, 15); // Вызов элемента для объекта

// start.

pStart~>ShowTime(); // вызов элемента через указатель

//на объект.

return 0;

}

Указатель this

Любая функция-элемент класса, не являющаяся статической (что это такое, выяснится позднее) имеет доступ к объекту, для которого она вызвана, через посредство ключевого слова this. Типом this является имя_класса*.

class Dummy {

void SomeFunc(void) {...};

public:

Dummy();

};

Dummy::Dummy)

{

SomeFunc();

this->SomeFunc();

(*this).SomeFunc();

}

В этом примере каждый оператор конструктора вызывает одну и ту же функцию SomeFunc (). Поскольку функции-элементы могут обращаться к элементам класса просто по имени, подобное использование указателя this довольно бессмысленно. Это ключевое слово чаще всего применяется для возврата из функции-элемента указателя или ссылки на текущий объект (вы увидите это позже, когда будут рассматриваться функции-операции).

На самом деле this является скрытым параметром, передаваемым функции-элементу класса при вызове. Именно этим функция-элемент (не статическая) отличается от обычной функции. При вызове функции-элемента компилятор генерирует код, который после всех указанных в вызове параметров помещает на стек указатель на объект, для которого функция вызвана. Поэтому, например, нельзя вызвать функцию-элемент через обычный указатель на функцию. Указатель на функцию-элемент объявить можно, но в нем явно должен быть специфицирован класс, например:

long (Dummy::*fPtr)();

fPtr = &Dummy::SomeFunc;

Привести такой указатель к обычному типу указателя на функцию невозможно. При его разыменовании всегда нужно указывать конкретный объект, и для этого в C++ предусмотрены две новых операции “. *” и “->*”. Первая из них применяется с непосредственным объектом, вторая — с указателем на объект. Вызов фун.кции-элемента через указатель выглядит так:

1 = (dummyObj.*fptr)();

1 = (dummyPtr->*fptr)();

Все сказанное приложимо и к элементам данных. На них тоже можно ссылаться с помощью указателей, которые нельзя привести к обычному типу указателя. На самом деле они содержат не адрес некоторой ячейки памяти, а смещение элемента данных относительно начала объекта. Для разыменования указателей на элемент данных используются те же операции:

double Dummy::*dptr;

dptr = &Dummy::someData;

d = dumrnyObj . *dptr;

d = duinmyPtr->*dptr;

Приоритет и правило ассоциации у этих специфических операций те же, что и у обычного разыменования (*) и других одноместных операций (14, справа налево).

Статические элементы класса

Можно объявить элемент класса (данные или функцию) как статический.

Статические элементы данных

Статический элемент данных является по существу глобальной переменной с областью действия в классе и разделяется всеми представителями класса. Он только один, вне зависимости от того, сколько представителей имеет класс. На самом деле статический элемент данных существует даже в том случае, когда никаких представителей класса не создано.

Помимо объявления в определении класса, статический элемент данных должен еще и определяться:

class SomeClass

{

static int iCount;

// Объявление статического

// элемента.

//.. .

};

int SomeClass::iCount = 0;

// Определение статического

// элемента.

Обращаться к открытым статическим элементам класса можно либо через любой его представитель операциями “.” и “->”, либо с помощью операции разрешения области действия (SomeClass : : iCount). Последний способ предпочтительнее, так как ясно показывает, что элемент не связан с конкретным объектом.

Статические элементы-функции

Функция класса, объявленная с модификатором static, не связывается ни с какими его конкретными представителями. Другими словами, ей не передается указатель this в качестве скрытого параметра. Это означает, что:

Статические функции-элементы класса могут передаваться процедурам API Windows в качестве возвратно-вызываемых, поскольку не предполагают наличия на стеке параметра this. Обычные функции-элементы для этого не годятся.

Специальные функции-элементы класса

Специальными функциями-элементами называют функции, которые могут вызываться компилятором неявно. Это может происходить при создании и уничтожении представителей класса, при их копировании и преобразовании в другие типы. К таким функциям относятся:

Конструктор

Конструктор имеет то же имя, что и класс. Он вызывается компилятором всегда, когда создается новый представитель класса. Если в классе не определен никакой конструктор, компилятор генерирует конструктор по умолчанию (не имеющий параметров).,Относительно конструкторов имеют место такие правила:

Поскольку конструктор не возвращает значений, то для сигнализации об ошибке при инициализации объекта, если требуется, нужно применять механизм управления исключениями, о котором мы будем говорить в 10-й главе.

Можно вызвать конструктор для инициализации уже существующего объекта, если перегрузить глобальную операцию new таким образом, чтобы она принимала дополнительный аргумент — указатель типа void*. Это называют размещающей формой операции; мы о ней уже упоминали в прошлой главе. Такая методика иногда применяется для глобальных представителей класса, если их нужно инициализировать после выполнения каких-то предварительных действий. Вот пример:

#include <new.h>

// Операция new, допускающая форму размещения:

inline void *operator new(size_t, void* p)

{

return p;

}

class Dummy

{

public:

Dummy() // Конструктор.

};

Dummy gblDummy;// Глобальный объект.

int main ()

{

InitSystem(); // Какие-то проверки

// и инициализации.

new(&gblDummy) Dummy; // Вызывает конструктор

// для gblDummy.

return 0;

}

Элементы данных класса часто инициализируют в теле конструктора, присваивая им соответствующие значения. Однако существует альтернативный механизм инициализации. Он использует список инициализации элементов.

Список инициализации следует за заголовком (сигнатурой) определения конструктора после двоеточия и состоит из инициализаторов элементов данных и базовых классов, разделенных запятыми. Каждому элементу списка передается один или несколько параметров, требуемых для инициализации.

Вот простейший пример класса с двумя перегруженными конструкторами, в одном из которых применяется обычный способ инициализации в теле функции, а во втором — список инициализации элементов:

class Time { int hr, min;

public:

Time(int h)

{

hr = h; min = 0;

}

Time(int h, int m): hr(h), min(m)

{

}

};

Тело второго конструктора, как видите, пусто.

Список инициализации является единственным средством присвоения значений элементам данных класса, объявленным как const или как ссылки (а также закрытым элементам базовых классов).

 

Конструктор копии

Конструктор копии является конструктором специального вида, который принимает в качестве параметра ссылку или константную ссылку на объект данного класса. Он автоматически вызывается компилятором, когда вновь создаваемый объект инициализируется значениями существующего объекта:

class Time {

int hr, min;

public:

Time(int h, int m): hr(h), min(m) {}

Time(const Time &src) // Конструктор копии.

{ hr = src.hr; min = src.min; } //

};

int main()

(

Time start (17,45); // Вызывается первый конструктор.

Time current = start; // Вызывается конструктор копии.

return 0;

}

Если вы не предусмотрели в классе конструктор копии, компилятор генерирует конструктор копии по умолчанию, который производит простое копирование данных объекта в новый представитель класса. Если класс содержит какие-то указатели или ссылки, то такое копирование скорее всего будет бессмысленным или опасным.

Иногда, когда копирование объектов класса в принципе не может привести ни к чему хорошему, имеет смысл объявить конструктор копии (это может быть просто “пустышка”) в закрытом разделе определения класса. Тогда пользователь класса не сможет создавать копии существующих объектов.

Операция присваивания

Операция присваивания — это функция-элемент класса с именем operator=, которая принимает в качестве своего единственного параметра ссылку или константную ссылку на объект данного класса. Она вызывается компилятором, когда существующему объекту присваивается другой объект. Если операция присваивания не предусмотрена, компилятор генерирует ее по умолчанию. В этом случае при присваивании будет выполняться поэлементное (как говорят, поразрядное) копирование данных объекта.

Как конструктор копии, так и операция присваивания выполняют, по видимости, одинаковые действия. Однако конструктор копии вызывается при инициализации вновь создаваемого объекта, в то время как операция присваивания служит для изменения содержимого существующих объектов.

Вот пример класса с операцией присваивания:

class Time { int hr, min;

public:

Time(int h, int m): hr(h), min (m) {}

Time &operator=(const Times); // Операция присваивания.

};

Time &Time::operator=(const Time &src)

{

if(&src == this) // Проверка на самоприсваивание.

error("Self assignment!");

hr = src.hr;

min = src.min;

}

return *this; // Возвращает ссылку на свой объект.

int main() {

Time start (17,45);

Time current (18, 0);

start = current; // Вызывает operator=.

return 0;

}

Здесь, кстати, показан прием проверки на самоприсваивание, позволяющей предотвратить присваивание объекта самому себе.

Обычно операцию присваивания определяют так, чтобы она возвращала ссылку на свой объект. В этом случае сохраняется семантика арифметических присваивании, допускающая последовательные присваивания в выражении (т. е. с = b = а;).

Параметры конструктора копии и операции присваивания могут иметь тип либо имя_класса&, либо const имя_класса&. Последнее предпочтительнее, так как простая ссылка на класс не позволяет копировать константные объекты.

Если класс содержит указатели или ссылки, может быть целесообразным, как и в случае конструктора копии, запретить присваивание объектов, объявив операцию присваивания в закрытом разделе класса.

Деструктор

Деструктор является противоположностью конструктора. Он вызывается при уничтожении объектов и должен производить необходимую очистку объекта перед освобождением занимаемой им памяти

Именем деструктора должно быть имя класса, которому предшествует тильда (~). Свойства деструкторов таковы:

Операции класса new и delete

Класс может определять свои собственные операции new и delete (new[] и delete [] для массива объектов):

Ниже приведен довольно длинный пример, демонстрирующий определение операций класса new и delete, а также глобальных new [ ] и delete []. Вывод программы позволяет проследить порядок вызовов конструкторов/деструкторов и операций new/delete при создании автоматических и динамических объектов.

Листинг 8.1. Определение операций класса new и delete

////////////////////////////////////

// NewDel.cpp: Операции класса new и delete.

//

#pragma hdrstop

#include <condefs.h>

#include <stdlib.h>

#include <stdio.h>

#include <string.h>

#define trace(msg)

printf(#msg "\n")

#define MaxStr 32

void *operator new[](size_t size)

// Замена глобальной

// new[].

{

trace(Global new [ ] .);

return malloc(size);

}

void operator delete[](void *p)

// Замена глобальной

// delete[].

{

trace(Global delete [].);

free(p) ;

}

class Hold {

char *str;

// Закрытый указатель на строку.

public:

Hold(const char*) ;

~Hold() ;

void *operator new(size t);

// Операция new класса.

void operator delete(void*);

// Операция delete класса.

void Show(void)

{ printf("%s\n", str);

}

};

Hold::Hold(const char *s)

// Конструктор.

{

trace (Constructor.) ;

str = new char[MaxStr];

// Вызов глобальной new[].

strncpy(str, s, MaxStr);

// Копирование строки в объект.

str[MaxStr-1] = 0;

// На всякий случай...

}

Hold::~Hold ()

// Деструктор.

{

trace(Destructor.);

delete[] str;

// Очистка объекта.

}

void *Hold::operator new(size_t size)

{

trace(Class new.);

return malloc (size);

)

void Hold::operator delete(void *p)

{

trace(Class delete.);

free(p) ;

)

void Func()

// Функция с локальным объектом.

{

Hold funcObj ("This is a local object in Func.");

funcObj.Show() ;

}

int main () {

Hold *ptr;

Hold mainObj ("This is a local object in main.");

mainObj.Show ();

trace (*);

ptr = new Hold("This is a dynamic object.");

ptr->Show();

delete ptr;

trace (*);

FuncO ;

trace (*);

return 0;

}

Результат работы программы показан на рис. 8.1.

Рис. 8.1 Программа NewDel

Пример заодно поясняет, зачем нужен деструктор. Здесь он удаляет динамически созданную строку, адрес которой хранится в объекте. Если не определить деструктор, то генерированный компилятором деструктор по умолчанию удалит сам объект, но не строку, которая окажется “потерянной”. Подобные упущения являются распространенной причиной утечек памяти.

Функции преобразования

Объекты класса могут быть преобразованы в другие типы (или созданы из других типов) с помощью операций приведения типа или конструкторов преобразования.

Конструкторы преобразования

Если конструктор класса А имеет единственный параметр типа В, то объект В может быть неявно преобразован в класс А с помощью такого конструктора. Другими словами, компилятор может сам вызывать такой конструктор, чтобы “из В сделать А”. Возьмите пример из предыдущего раздела. Локальный объект можно было бы инициализировать по-другому:

class Hold {

char *str;

public:

Hold(const char*);

//...

};

main () {

Hold mainObj = "This is a local object in main.";

//. . .

return 0;

Таким образом, в этом примере объявленный в классе конструктор Hold(const char*) является по сути конструктором преобразования.

Ключевое слово explicit

Можно запретить вызовы конструктора с одним параметром в качестве конструктора преобразования, объявив его с ключевым словом explicit. Тем самым будут запрещены неявные вызовы конструктора, подобные показанному в предыдущем параграфе:

class Hold {

char *str;

public:

explicit Hold(const char*);

//. . .

};

main () {

//

// Неявное преобразование недопустимо:

//

// Hold mainObj = "This is a local object in main.";

//...

return 0;

}

Такой конструктор можно вызывать только явным образом, т. е.

Hold mainObj("This is a local object in main.");

или

Hold mainObj = Hold("This is a local object in main.");

Последняя форма предполагает вызов конструктора копии, который должен обязательно определяться, если класс содержит указатели на динамические объекты подобно классу из листинга 8.1. Там этого не сделано.

Операции приведения

В классе можно определять элементы-функции, которые будут обеспечивать явное преобразование типа данного класса в другие типы. Эти функции называют операциями приведения или процедурами преобразования. Синтаксис их следующий:

operator имя_нового_типа();

Процедуры преобразования характеризуются следующими правилами:

Вот пример процедуры преобразования:

#include <stdio.h>

class Time { int hr, min;

public:

Time(int h, int m): hr(h), min(m) {}

// Конструктор.

operator int();

// Процедура преобразования.

};

Time::operator int() {

// Преобразует время в число секунд от начала суток:

return (3600*hr + 60*min);

}

main ()

{

int h = 7;

int m = 40;

Time t (h, m);

//

// Последний параметр вызывает Time::operator int():

//

printf("Time: %02d:%02d = %5d seconds.\n", h, m, (int)t);

return 0;

Константные объекты и функции-элементы

Можно создать представитель класса с модификатором const. Тем самым гарантируется, что после инициализации содержимое объекта нельзя будет изменить. Компилятор C++Builder выдает предупреждение в случае, если для объекта вызывается функция-элемент, не объявленная как const. Другие компиляторы могут выдать сообщение об ошибке и отказаться транслировать подобный код.

Со своей стороны, константная функция-элемент

class Time {

int hr, min;

public:

Time(int h, int m): hr(h), min(m) {}

void SetTime(int h, int m) { hr = h; min = m;

}

void GetTime(int&, int&) const;

}

void Time::GetTime(int &h, int &m) const {

h = hr;

m = min;

//

// Следующий оператор здесь был бы некорректен:

//

// min = 0;

int main ()

{

Time t(17, 45); // Обычный объект.

const Time ct(18, 0); // Константный объект.

int h, m;

ct.GetTime(h, m); // Вызов const-функции для const-объекта. t.SetTime(h, m) ;

//

// Следующий вызов некорректен:

// // ct.SetTime(0, 0) ;

return 0;

}

Ключевое слово mutable

Константная функция-элемент “обещает” не изменять значений элементов данных класса, что позволяет применять ее на константных объектах. Тем не менее, в некоторых ситуациях имеет смысл разрешить некоторым элементам меняться даже у константных объектов. Например, некоторый существенный набор данных изменять ни в коем случае нельзя, в то время как отдельный элемент, скажем, некоторое сообщение, может и должно меняться. В этом случае можно объявить элемент данных с ключевым словом mutable:

class AnyClass {

int value;

mutable const char *msg;

public.:

AnyClass (): value (0), msg(NULL) {}

int GetValueO const;

// ... };

j nt AnvClass::Get Value() const

{

msg - "New message!";

// Допускается, поскольку msg - mutable.

//

// value изменять нельзя:

//

// value = 111;

//

return value;

}

Модификатор mutable не может применяться совместно с const или static (в приведенном примере все верно, поскольку const относится не к msg, а к содержимому строки, на которую он ссылается).

“Друзья”

Спецификаторы доступа позволяют указать, к каким элементам класса могут обращаться функции, в него не входящие. Однако могут быть случаи, когда целесообразно разрешить некоторому классу или функции обращаться к закрытым или защищенным элементам данного класса. Это можно сделать с помощью ключевого слова friend.

“Друзьями” класса могут быть объявлены другие классы или отдельные функции, как обычные, так и являющиеся элементами некоторых классов. Друзья могут объявляться в любом из разделов определения класса (закрытом, защищенном или открытом), — в каком именно, не имеет значения. В любом случае дружественный класс или функция будет иметь полный доступ к элементам класса.

Вот пример объявления Друзей класса:

class SomeClass (

friend class AnotherClass;

friend void regularFunc (int);

friend void OtherClass::MemFunc(double);

//...

};

Следует иметь в виду такие правила:

Перегрузка функций-элементов

Функции-элементы класса могут быть перегружены подобно обычным функциям; несколько функций-элементов могут иметь одно и то же имя, если их можно однозначно идентифицировать по списку аргументов. Вы уже встречались в этой главе с перегруженным конструктором. Это весьма распространенная ситуация. Вот еще подобный пример:

class Time {

long sec; public:

Time(): sec(O) {}

Time(long s): sec(s) {}

Time(int h, int m) {

sec = 3600*h + 60*m;

}

//... };

int main ()

{

Time tl; // Вызывает Time::Time().

Time t2(86399); // Вызывает Time::Time(long).

Time t3(ll, 33); // Вызывает Time::Time(int, int).

//. . .

return 0;

}

Перегрузка операций

+

*

/

%

/\

&

|

 ~ 

!

=

<

>

+=

-=

*=

/=

%=

^=

  &=

|=

<<

>>

>>=

<<=

= =

! =

<=

>=

&&

 ||

++

'

->*

->

()

[]

new

delete

new[]

delete

[ ]

Язык C++ позволяет переопределять для классов существующие обозначения операций. Это называется перегрузкой операций. Благодаря ей класс можно сделать таким, что он будет вести себя подобно встроенному типу. В классе можно перегрузить любые из следующих операций:

Нельзя перегружать операции:

.

.*

::

?:

 

Функции-операции, реализующие перегрузку операций, имеют вид

operator операция ([операнды]) ;

Если функция является элементом класса, то первый операнд соответствующей операции будет самим объектом, для которого вызвана функция-операция. В случае одноместной операции список параметров будет пуст. Для двухместных операций функция будет иметь один параметр, соответствующий второму операнду. Если функция-операция не является элементом класса, она будет иметь один параметр в случае одноместной операции и два — в случае двухместной.

Для перегрузки операций существуют такие правила:

Примеры

Ниже мы приводим два примера классов с перегруженными операциями. Первый из них определяет рудиментарный класс строк, допускающих конкатенацию с помощью знака сложения. Второй пример показывает перегрузку индексации.

Листинг 8.2. Перегрузка операции сложения

///////////////////////////////////////

// StrAdd.cpp: Демонстрация перегрузки сложения для строк.

//

#pragma hdrstop

#include <condefs.h>

#include <string.h>

#include <stdio.h>

class String {

char *str; // Указатель на динамическую строку.

int len; // Длина строки.

String(int); // Вспомогательный конструктор. public:

String(const Strings); // Конструктор копии.

String(const char*); // Конструктор преобразования.

~String () ;

String Soperator=(const Strings);

String operators- (const Strings);

friend String operator+(const Strings, const Strings);

void Show () ;

};

String::String(int size)

{

len = size;

str = new char[len + 1]; // +1 байт для завершающего 0.

}

String::String(const String ssrc)

{

len = src.len;

str = new char[len + 1];

strcpy(str, src.str);

}

String::String(const char *s)

{

len = strlen(s) ;

str = new char[len + 1];

strcpy(str, s);

String::~String()

{

delete [] str;

/////////////////////////////////////////

// Операция присваивания.

//

String SString::operator=(const String &op)

{

delete [] str; // Удаление старых данных.

len = op.len;

str = new char[len + 1]; // Выделение новой строки.

strcpy(str, op.str);

return *this;

}

///////////////////////////////////////////

// Функция-элемент operator+0.

//

String String::operators- (const String &op)

{

String temp(len + op.len); // Временный объект.

strcpy(temp.str, str); // Копирование первой строки.

strcat(temp.str, op.str); // Присоединение второй строки.

return temp;

}

////////////////////////////////////////////

// Дружественная функция operator+() Аналогична предыдущей,

// но допускает С-строку в качестве первого операнда.

//

String operator+(const String Sfop, const String &sop)

{

String temp(fop.len + sop.len);

strcpy(temp.str, fop.str);

strcat(temp.str, sop.str);

return temp;

}

void String::Show()

{

printf("%s: %d \n", str, len);

}

irit main()

{

char cStr[] °= "This is а С string! ";

String rirst = "First String string. ;

String second = "Second String string. ";

String resStr = "";

resStr.Show() ;

resStr = first + second; // Вызывает операцию класса.

resStr.:Shp,w ();

resStr = cStr + resStr; // Вызывает дружественную

// операцию reeStr.Show()

resStr = first + second + cStr; // Обе операции - из

// класса. resStr.Show () ;

return 0;

}

На рис. 8.2 показан вывод программы. Пример позволяет пояснить, почему перегруженные функции операций часто делают друзьями, а не элементами класса. Это делается для того, чтобы передать функции первый операнд в параметре, а не как объект *this. В одном из операторов функции main () из примера первый операнд сложения — С-строка. Но это не объект класса, и компилятор никак не может вызвать String: :operator+ (Strings) . Однако есть дружественная функция operator+ (Strings, Strings). Поскольку имеется конструктор преобразования char* в String, компилятор автоматически приводит первый операнд к типу String, создавая на стеке временный объект, и затем выполняет сложение с помощью дружественной функции. Это аналог “возведения типа”, происходящего в арифметических выражениях.

На самом деле функция-элемент operator+ (Strings) здесь является излишней. Можно было бы'обойтись одной дружественной функцией сложения.

В данном классе необходима реализация конструктора копии. Компилятор вызывает его при передаче возвращаемого значения функциями-операциями operator+ (). На стеке конструируется копия локального автоматического объекта temp (см. листинг), который при завершении функции выходит из области действия и удаляется. Конструктор копии по умолчанию не годится, так как класс содержит указатель на динамическую строку.

Рис. 8.2 Программа StrAdd

Листинг 8.3. Перегрузка операции индексации

/////////////////////////////////////////

// Index.срр: Строка в качестве индекса.

//

#pragma hdrstop

#include <condefs.h>

#include <stdio.h>

#include <string.h>

const int Maxltems = 16;

class AArr {

int nitems;

char * keys[Maxltems];

char *items[Maxltems];

static const char error [];

public:

AArr() { nitems =0; }

~AArr();

void Addltem(const char*, const char*);

const char ^operator[](const char*);

};

consk char AArr::error[] = "*** Not found. ***";

/////////////////////////////////////////

// Деструктор: удаляет динамические строки,

// созданные Addltem().

//

AArr::~AArr ()

{

for (int j=0; j<nltems; j++) {

delete [] keys[j];

delete[] items[j];

}

///////////////////////////////////////////

// Создает новую запись с указателями в keys[] и items[].

//

void AArr::Addltem(const char *key, const char *data)

{

if (nitems < Maxltems) {

keys[nitems] == new char[strlen(key)+1];

items[nitems] = new char[strlen(data)+1] ;

strcpy(keys[nitems], key);

strcpy(items[nitems], data);

n Items++; }

///////////////////////////////////////////

// Перегруженная индексация: ищет запись с указанным ключом. //

const char *AArr::operator[1 (const char *idx)

(

int j ;

for (j=0; j<nltems; j++)

if (!strcmp(keys[j], idx)) break;

if (j < nitems) return items[j];

else

return error;

}

int main() {

AArr a;

// Несколько записей... a.AddItem("first", "String One!");

a.AddItem("second", "String Two!");

a.AddItem("another", "Another String!");

a.AddItem("one more", "One More String!");

a.AddItem("finish", "That's all folks!");

// Проверка:

char *i;

i = "second";

printf("\n%s: %s\ri", i, a[i]);

i = "one more";

printf ("%s: %s\n", i, a[i]);

i = "abracadabra";

printf ("%s: %s\n", i, a[i]);

i = "finish"; printf("%s: %s\n", i, a[i]);

return 0;

}

Этот пример не требует особых пояснений. Здесь перегружается функция-операция с именем Aarr: : operator []. Получившийся класс ведет себя как массив с “индексами”-строками. Вывод программы показан на рис. 8.3.

Рис. 8.3 Программа Index

Операция вызова объекта

Перегрузка операции вызова operator () () позволяет “вызывать” объект класса, как функцию. Возвращаемое значение будет чем-то вроде значения объекта по умолчанию. Но вообще эта операция может производить любые действия над объектом. Вот пример операции вызова:

class AClass {

int x;

public:

AClass(int n) { x = n; }

int operator ()(void) { return x; }

//. . .

};

int main() {

AClass object = 100;

//...

int у = objectO; // Объект вызывается, как функция.

return 0;

}

Некоторые замечания

При перегрузке операций полезно помнить следующее:

Наследование

Класс в C++ может наследовать элементы-данные и элементы-функции от одного или нескольких базовых классов. Сам класс называется в этом случае производным по отношению к базовым классам или классом-потомком. В свою очередь, производный класс может являться базовым по отношению к другим классам.

Принцип наследования, или порождения новых классов, позволяет абстрагировать (инкапсулировать) некоторые общие свойства и поведение в одном базовом классе, которые будут наследоваться всеми его потомками.

Наследование позволяет также модифицировать поведение базового класса. Производный класс может переопределять некоторые функции-элементы базового класса, оставляя основные свойства класса в неприкосновенности .

Синтаксис производного класса следующий:

class имя класса: ключ доступа имя_базового класса [, ...] {

тело_объявления_класса } ;

Ключ_доступа — это одно из ключевых слов private, protected или public.

Доступ к базовым классам

Ключ доступа определяет “внешний” доступ к элементам базового класса через объекты производного. Что касается доступа самого производного класса к элементам базового класса, то ключ доступа на него не влияет. Для производного класса доступны разделы protected и public базового класса; раздел private строго недоступен вне области действия базового класса.

Для доступа к элементам базового класса через производный можно сформулировать такое правило: права доступа, определяемые для них базовым классом, остаются неизменными, если они такие же или строже, чем специфицировано ключом доступа. В противном случае права доступа определяются ключом в определении производного класса.

Например, при наследовании с ключом public права доступа к элементам базового класса остаются неизменными; при закрытом наследовании (ключ private) все элементы базового класса будут недоступны за пределами производного класса.

При закрытом наследовании можно сделать некоторые открытые функции базового класса открытыми в производном, если переобъявить их имена в производном классе:

class First { public:

void FFunc(void) ;

//... }

class Second: private First { public:

First::FFunc; // First::FFunc() открыта в классе Second.

//.. .

}

Нужно сказать, что в прикладном программировании применяется почти исключительно открытое наследование.

Простое наследование

При простом, наследовании производный класс порождается всего одним базовым классом. Вот пример:

#include <stdio.h>

#include <string.h>

class Time { // Базовый класс - время.

int hr, min;

public:

Time(int h=12, int m=0): hr(h), min(m) {}

void SetTime(int h, int m) ( hr = h; min = m; }

void Show() ;

};

void Time::Show() {

printf("%02d:%02d", hr, min);

}

class Alarm: public Time { // Класс сообщений таймера.

char *msg;

public:

Alarm(char*);

~Alarm() { delete [] msg; }

void SetMsg(char*);

void Show(); // Переопределяет Time::Show (). };

Alarm::Alarm(char *str) ;

{

msg = new char[strlen (str) + 1];

strcpy(msg, str);

}

void Alarm::SetMsg(char *str) {

delete [] msg;

msg = new char[strlen (str) + 1];

strcpyfmsg, str);

}

void Alarm::Show()

{

Time::Show(); // Вызов базовой Show(). printf(": %s\n", msg);

int main () {

Alarm a = "Test Alarm!!!"; // Время по умолчанию 12:00.

а.Show ();

a.SetTime(7, 40); // Функция базового класса. а.Show () ;

а.SetMsg("It's time!); // Функция производного класса. a.Show();

return 0;

}

Конструкторы, деструкторы и наследование

Конструкторы не наследуются. Это утверждение требует некоторых пояснений. Оно означает, что если в базовом классе имеются конструктор с некоторыми параметрами, он не будет вызываться автоматически, если вы . попробуете создать объект производного класса с такими параметрами. Для этого нужно написать конструктор производного класса, в котором конструктор базового класса будет вызываться через посредство списка инициализации. О нем мы уже говорили выше в связи с инициализацией элементов данных класса. Базовые классы в смысле инициализации ничем от них не отличаются.

Если в списке инициализации конструктора отсутствует вызов какого бы то ни было конструктора базового класса, компилятор все равно вызовет для последнего конструктор по умолчанию, т. е. конструктор без параметров. В примере предыдущего параграфа объявлен конструктор базового класса, который может вызываться без параметров, поскольку для него определены аргументы по умолчанию. Если вы не поленитесь пройти по этому примеру в отладчике, то увидите последовательность вызовов при создании объекта производного класса в функции main () .

Однако базовый конструктор можно вызвать явно через список инициализации. Класс из предыдущего параграфа нужно модифицировать примерно так:

class Alarm: public Time { // Класс сообщений таймера.

char *msg;

public:

Alarm(char*);

Alarmfchar*, int, int); // Новый конструктор.

~Alarm() { delete[] msg; }

void SetMsg(char*) ;

void Show(); // Переопределяет Time:: Show ().

//. . .

Alarm::Alarm(char *str, int h, int m): Time(h, m) {

msg = new char[strlen(str) + 1];

strcpy(msg, str);

}

С другой стороны, деструкторы базовых классов никогда явно не вызываются. Деструкторы, можно сказать, даже не имеют имен. Компилятор автоматически генерирует вызовы всех необходимых деструкторов.

Сложное наследование

Язык C++ допускает не только простое, но и сложное наследование, т. е. наследование от двух и более непосредственных базовых классов. Это позволяет создавать классы, комбинирующие в себе свойства нескольких независимых классов-предков.

Это чаще всего имеет смысл, когда у вас есть некоторый набор понятий, которые могут более или менее независимо комбинироваться с различными элементами другого набора понятий. Простейший пример. Имеется понятие “растение”. Бывают “культурные растения”, и бывают “дикорастущие растения”. С другой стороны, растение может иметь или не иметь “товарной ценности”, т. е. быть полезным или бесполезным с коммерческой точки зрения. Если говорить о товарной ценности, то тут у растений бывают “цветы” и “плоды” и т. д. Все это образует довольно развернутую структуру, которая может порождать понятия вроде “дикое растение, цветы которого можно продавать на рынке”. (Возможно, кстати, и такое: “дикое растение, цветы которого имеют товарную ценность, но которые нельзя продавать на рынке”!) А можно, с некоторыми модификациями, говорить то же самое не о растениях, а о животных или веществах, минералах. И есть не только “товарные” сущности, но и сорняки, вредители. И так далее.

Очевидно, здесь существует ряд довольно независимых категорий — “растение”, “товар”, “культурность” и прочее. Подобная структура — отличный кандидат на реализацию в виде иерархии классов со сложным наследованием.

Кстати, в языке Object Pascal, реализованном в сходном продукте Борланда — Delphi, — нет сложного наследования, что в ряде случаев может значительно ограничить его полезность в сравнении с C++.

Для иллюстрации сложного наследования возьмем последний пример с “сообщениями таймера”. Понятия времени и понятие сообщения — независимые, и, возможно, в программе будут другие классы, родственные “времени” и “сообщению”. Поэтому вполне разумным будет определить для них отдельные классы и породить от них третий класс, применив методику сложного наследования:

#include <stdio.h>

#include <string.h>

//////////////////////////////////////

// Базовый класс - время.

//

class Time {

protected;

int hr, min;

public:

Time(int h=12, int m=0): hr(h), min (m):{}

void Show() ;

};

void Time::Show() {

printf("%02d:%02d\n", hr, min);

}

//////////////////////////////////////////////

// Базовый класс - сообщение. //

class Message { protected:

char *msg;

public:

Message(char*) ;

~Message () { delete[]msg; }

void Show () ;

}

Message::Message(char*msg)

// Конструктор Message. {

msg = new char[strlen(str)+1];

strcpy(msg, str);

}

void Message::Show()

{

printf(%s\n", msg);

}

////////////////////////////////////////////////

// Производный класс сообщений таймера.

//

class Alarm: public Time, public Message { public:

Alarm(char* str, int h, int m): Time(h, m), Message(str) {}

void Show ();

};

Alarm::Show() // Переопределяет базовые Show().

{

printf("%02d:%02d: %s\n", hr, min, msg);

}

int main() {

Alarm a("Test Alarm!!!", 11, 30);

a.Show() ;

return 0;

}

Вы видите, что конструктор производного класса Alarm имеет пустое тело и список инициализации, вызывающий конструкторы базовых классов. Элементы данных базовых классов объявлены как protected, чтобы можно было непосредственно обращаться к ним в функции Show () производного класса.

Неоднозначности при сложном наследовании

В иерархии классов со сложным наследованием вполне может получиться так, что класс косвенно унаследует несколько экземпляров некоторого базового класса. Если В и С оба являются наследниками A, a D наследует В и С, то D получает двойной набор элементов класса А. Это может приводить к неоднозначностям при обращении к ним, что будет вызывать ошибки времени компиляции. Вот иллюстрация:

class A { public:

int AData;

void AFunc ();

II... };

class B: public A

{

// ... };

class C: public A {

// ...

};

class D: public B, public С // Двукратно наследует А.

{

// ... ,

};

int main (void)

{

D d;

d.AData = 0; // Ошибка! d. AFunc ();

// Ошибка!

return 0;

}

В этом примере строки в main () , содержащие обращения к унаследованным от А элементам, будут вызывать ошибку компиляции с выдачей сообщения о том, что элемент класса неоднозначен. Однако эту неоднозначность несложно устранить, применив операцию разрешения области действия, например, так:

d.B::AData= 0;

d.С::AFunc();

Виртуальные базовые классы

В качестве альтернативы операции разрешения области действия при сложном наследовании, подобном описанному в предыдущем параграфе, можно потребовать, чтобы производный класс содержал только одну копию базового. Этого можно достигнуть, описав базовый класс при наследовании от него как виртуальный с помощью ключевого слова virtual. Вот модификация предыдущего примера, которая делает класс А виртуальным базовым классом:

class A { public:

int AData;

void AFunc ();

// . .. };

class B: public virtual A // A - виртуальный базовый класс.

{

}:

class C: public virtual A // A - виртуальный базовый класс.

{

// ...

};

class D: public B, public С // Содержит только одну копию А.

{

// ...

};

int main(void) {

D d;

d.AData = 0; // Теперь неоднозначности не возникает.

d.AFunc();

//

return 0;

}

Виртуальные базовые классы — более хитрая вещь, чем может показаться с первого взгляда. Если, допустим, конструкторы “промежуточных” классов В и С явно вызывают в своих списках инициализации какой-то конструктор с параметрами класса А, то снова возникает неоднозначность — какой набор параметров компилятор должен использовать при конструировании той единственной копии А, которая содержится в С?

Поэтому, если при конструировании производного класса должен инициализироваться виртуальный базовый класс (пусть даже он инициализируется косвенным образом через конструкторы промежуточных классов), в списке инициализации требуется явно указать инициализатор виртуального базового класса, примерно так:

D: :D(...) : В(. . .) , С(. . .) , А(.. .) {

// ... }

Все выглядит так, как если бы виртуальный базовый класс был непосредственным предком производного класса D “в обход” промежуточных классов иерархии. Поэтому, если требуется вызов конструктора виртуального базового класса, последний обязательно должен присутствовать в списке инициализации производного класса, даже если реально не возникает никакой неоднозначности. Такая ситуация существует, например, в библиотеке OWL, которая имеет иерархию со сложным наследованием и виртуальными базовыми классами. Конструктор главного окна приложения там должен определяться так:

TMyWindow::TMyWindow(TWindow *parent, const char *title):

TFrameWindow(parent, title), TWindow(parent, title) {

// . . . }

TWinoow — виртуальный базовый класс, поэтому он должен инициализироваться отдельно, хотя это выглядит совершенно излишним, так как TFrameWindow, промежуточный класс, сам вызывает конструктор TWindow с теми же параметрами.

Виртуальные функции

Функции-элементы класса могут объявляться в C++ как виртуальные. Ключевое слово virtual заставляет компилятор генерировать для класса некоторую дополнительную информацию 6 функции. Происходит следующее: если виртуальная функция переопределяется в производном классе, и если имеется указатель или ссылка на базовый класс (которые могут с тем же успехом ссылаться на производный класс, поскольку производный объект есть в то же время и объект базового класса), то при обращении к функции через указатель (ссылку) будет вызвана правильная функция-элемент (т. е. соответствующая типу действительного объекта) — базового или одного из производных классов, в зависимости от типа конкретного объекта.

Не хочу показаться занудой, но, как мне кажется, стоит повторить, чем косвенное обращение к объекту (указатель или ссылка) в данном отношении отличается от прямого. Можно недвусмысленно объявить объект базового и объект производного классов. Потом можно присвоить объект производного класса переменной базового типа. Не требуется даже никаких приведений, потому что, как я уже говорил, производный объект является объектом базового класса. “Автомобиль” есть “средство передвижения”. Однако при этом будет потеряна всякая специфика “автомобиля”, отличающая его от всех других средств передвижения, наземных, водных или воздушных. Но применение указателей или ссылок в объектно-ориентированных языках типа C++ приводит к тому, что объект сам может помнить, к какому типу он относится, и указатель на базовый тип может быть в данном случае снова приведен

Следующий пример покажет вам разницу между виртуальным и не виртуальным переопределением функции.

//////////////////////////////////////////////

// Virtual.cpp: Демонстрация виртуальной функции.

//

#pragma hdrstop

#include <condefs.h>

#include <stdio.h>

class One

{

// Базовый класс. public:

virtual void ShowVirtO

// Виртуальная функция.

{

printf("It's One::ShowVirt()!\n");

}

void ShowNonVirt() // Не-виртуальная функция.

{

printf("It's One::ShowNonVirt()!\n") ;

}

};

class Two: public One

{

// Производный класс. public:

virtual void ShowVirt()

(

printf ("It's Two::ShowVirtO !\n") ;

)

void ShowNonVirt ()

(

printf("If s Two::ShowNonVirt ()!\n") ;

) };

int main(void)

{

Two derived;

One *pBase = sderived;

pBase->ShowVirt(); // Вызовет Two::ShowVirt().

pBase->ShowNonVirt(); // Вызовет One::ShowNonVirt().

//

// Следующий вызов подавляет виртуальный механизм:

// pBase->One::ShowVirt();

// Явно вызывает One::ShowVirt().

return 0;

}

Результат работь! программы (рис. 8.4) показывает, что при обращении к виртуальной функции через базовый указатель будет'вызвана “правильная” функция, соответствующая типу действительного (производного) объекта.

Ключевое слово virtual при объявлении функции в производных классах не обязательно. Функция, однажды объявленная виртуальной, остается таковой во всех производных классах иерархии.

Рис. 8.4 Демонстрация виртуальной и не-виртуальной функции

Виртуальная функция не может быть статической.

Желательно, а иногда просто необходимо, объявлять виртуальным деструктор базового класса. Если этого не сделать, то при удалении динамического объекта, на который ссылается указатель базового класса, всегда будет вызываться только базовый деструктор вне зависимости от того, чем является данный объект. Это можно проиллюстрировать такой схемой:

class One { public:

~One () { /* ... */ }

};

class Two: public One

{

Something *s;

public:

Two()

{

s = new Something; // Выделение ресурса.

}

~Two()

{

delete s; // Очистка. } };

int main() {

One *pBase = new Two;

// ...

delete pBase; // Удаление динамического объекта.

return 0;

}

В данном примере при удалении объектаоперацией delete будет вызван только базовый деструктор ~0nе (), хотя объект принадлежит к производному классу. Чтобы вызывался правильный деструктор, следовало бы объявить его виртуальным в базовом классе:

virtual ~One() { /* ... */}

Чисто виртуальные функции и абстрактные классы

Виртуальная функция-элемент некоторого класса может быть объявлена чистой. Это выглядит так:

virtual тип имя функции(список параметров} = 0;

Другими словами, тело функции объявляется как ='0 (т. н. чистый спецификатор). Действительная реализация ее не нужна (хотя и возможна). Предполагается, что чисто виртуальная функция будет переопределяться в классах, производных от него. Класс, объявляющий одну или несколько чисто виртуальных функций, является абстрактным базовым классом. Нельзя создать представитель такого класса. Он может служить только в качестве базового для порождения классов, которые полностью реализуют его чисто виртуальные функции.

Если класс не определяет see чисто виртуальные функции абстрактного базового класса, то он также является абстрактным.

Абстрактные классы

Тем не менее, можно объявлять указатели или ссылки на абстрактный класс.

Смысл абстрактных базовых классов в том, что они способствуют лучшей концептуальной организации классовой иерархии и позволяют тем самым в полной мере использовать преимущества виртуальных механизмов C++.

Предположим опять-таки, что вы хотите работать с геометрическими фигурами — линиями, кругами и т. д. Конечно, имеет смысл определить общий базовый класс “фигура”. Тогда можно будет представлять наборы разнородных фигур в виде единообразных списков или массивов указателей. Но нет смысла создавать представителей собственно класса “фигура”. Не бывает в мире фигур просто. Фигура — это абстракция.

С другой стороны, каждый конкретный класс фигур должен иметь метод “нарисовать”. Чтобы этот метод был виртуальным, его должен объявлять базовый класс (как виртуальный, естественно). Но что может делать такой метод, если неизвестно, что рисовать? Конечно, можно написать функцию, которая ничего не делает или возвращает состояние ошибки. Но гораздо лучше объявить такую функцию-элемент базового класса как чистую. Тем самым будет образована виртуальная база для переопределения в производных классах, а также запрещено явное или неявное создание представителей базового класса.

Сказанное иллюстрирует следующий набросок иерархии классов:

class Shape { // Абстрактный базовый класс.

// . . .

public:

virtual -Shape () {} //На всякий случай...

virtual void Draw() =-0; //Чисто виртуальная функция. // . . . };

//

// Производные классы:

//

class Line: public Shape

{ // . . . public:

void Draw() {

// Определение тела

class Rectangle: public Shape

{

// . . .

public:

void Draw()

{

// . . .

}

//...

// И т.д.

Реализация виртуального механизма

Для реализации виртуальных свойств классов нужно обеспечить выбор нужной функции на этапе выполнения программы. Это называют поздним связыванием (объекта с его методами). Компилятор не может заранее разрешить обращение к виртуальной функции-элементу объекта, если последний представлен указателем или ссылкой. На этапе компиляции действительный тип объекта неизвестен. Поэтому компилятор делает примерно следующее:

Как видите, виртуальный механизм, как и все хорошее в этом мире, связан с некоторыми издержками, как в плане занимаемой памяти (виртуальная таблица), так и в плане времени выполнения (дополнительные косвенные ссылки при вызове). Однако эти издержки, как это ни удивительно, очень малы.

Заключение

Содержание данной главы практически исчерпывает объектно-ориентированные аспекты C++. Однако в языке еще немало различных возможностей, среди которых можно назвать управление исключительными ситуациями и шаблоны. Одним из таких аспектов C++ являются классы потоков ввода-вывода, изучением которых мы займемся в следующей главе. Реализация ввода-вывода может служить примером использования ряда объектно-ориентированных концепций, описанных выше.